diff --git a/web/components/avatar.css b/web/avatars/avatar.css similarity index 100% rename from web/components/avatar.css rename to web/avatars/avatar.css diff --git a/web/components/avatar.react.js b/web/avatars/avatar.react.js similarity index 99% rename from web/components/avatar.react.js rename to web/avatars/avatar.react.js index 14eb0a685..108f9ade1 100644 --- a/web/components/avatar.react.js +++ b/web/avatars/avatar.react.js @@ -1,69 +1,69 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; -import type { ResolvedClientAvatar } from 'lib/types/avatar-types'; +import type { ResolvedClientAvatar } from 'lib/types/avatar-types.js'; import css from './avatar.css'; type Props = { +avatarInfo: ResolvedClientAvatar, +size: 'micro' | 'small' | 'large' | 'profile', }; function Avatar(props: Props): React.Node { const { avatarInfo, size } = props; const containerSizeClassName = classnames({ [css.imgContainer]: avatarInfo.type === 'image', [css.micro]: size === 'micro', [css.small]: size === 'small', [css.large]: size === 'large', [css.profile]: size === 'profile', }); const emojiSizeClassName = classnames({ [css.emojiContainer]: true, [css.emojiMicro]: size === 'micro', [css.emojiSmall]: size === 'small', [css.emojiLarge]: size === 'large', [css.emojiProfile]: size === 'profile', }); const emojiContainerColorStyle = React.useMemo(() => { if (avatarInfo.type === 'emoji') { return { backgroundColor: `#${avatarInfo.color}` }; } return undefined; }, [avatarInfo.color, avatarInfo.type]); const avatar = React.useMemo(() => { if (avatarInfo.type === 'image') { return ( image avatar ); } return (
{avatarInfo.emoji}
); }, [ avatarInfo.emoji, avatarInfo.type, avatarInfo.uri, containerSizeClassName, emojiContainerColorStyle, emojiSizeClassName, ]); return avatar; } export default Avatar; diff --git a/web/components/thread-avatar.react.js b/web/avatars/thread-avatar.react.js similarity index 100% rename from web/components/thread-avatar.react.js rename to web/avatars/thread-avatar.react.js diff --git a/web/components/user-avatar.react.js b/web/avatars/user-avatar.react.js similarity index 100% rename from web/components/user-avatar.react.js rename to web/avatars/user-avatar.react.js diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js index a4c65fdc9..6a7b15dcb 100644 --- a/web/chat/chat-thread-composer.react.js +++ b/web/chat/chat-thread-composer.react.js @@ -1,211 +1,211 @@ // @flow import classNames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { getPotentialMemberItems, useSearchUsers, } from 'lib/shared/search-utils.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import css from './chat-thread-composer.css'; +import UserAvatar from '../avatars/user-avatar.react.js'; import Button from '../components/button.react.js'; import Label from '../components/label.react.js'; import Search from '../components/search.react.js'; -import UserAvatar from '../components/user-avatar.react.js'; import type { InputState } from '../input/input-state.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; type Props = { +userInfoInputArray: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, +threadID: string, +inputState: InputState, }; type ActiveThreadBehavior = | 'reset-active-thread-if-pending' | 'keep-active-thread'; function ChatThreadComposer(props: Props): React.Node { const { userInfoInputArray, otherUserInfos, threadID, inputState } = props; const [usernameInputText, setUsernameInputText] = React.useState(''); const dispatch = useDispatch(); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userInfoInputIDs = React.useMemo( () => userInfoInputArray.map(userInfo => userInfo.id), [userInfoInputArray], ); const serverSearchResults = useSearchUsers(usernameInputText); const userListItems = React.useMemo( () => getPotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, includeServerSearchUsers: serverSearchResults, }), [ usernameInputText, otherUserInfos, userSearchIndex, userInfoInputIDs, serverSearchResults, ], ); const userListItemsWithENSNames = useENSNames(userListItems); const onSelectUserFromSearch = React.useCallback( (user: AccountUserInfo) => { dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: [...userInfoInputArray, user], }, }); setUsernameInputText(''); }, [dispatch, userInfoInputArray], ); const onRemoveUserFromSelected = React.useCallback( (userID: string) => { const newSelectedUserList = userInfoInputArray.filter( ({ id }) => userID !== id, ); if (_isEqual(userInfoInputArray)(newSelectedUserList)) { return; } dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: newSelectedUserList, }, }); }, [dispatch, userInfoInputArray], ); const userSearchResultList = React.useMemo(() => { if ( !userListItemsWithENSNames.length || (!usernameInputText && userInfoInputArray.length) ) { return null; } const userItems = userListItemsWithENSNames.map( (userSearchResult: UserListItem) => { const { alertTitle, alertText, notice, disabled, ...accountUserInfo } = userSearchResult; return (
  • ); }, ); return ; }, [ onSelectUserFromSearch, userInfoInputArray.length, userListItemsWithENSNames, usernameInputText, ]); const hideSearch = React.useCallback( (threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadBehavior === 'keep-active-thread' || !threadIsPending(threadID) ? threadID : null, }, }); }, [dispatch, threadID], ); const onCloseSearch = React.useCallback(() => { hideSearch('reset-active-thread-if-pending'); }, [hideSearch]); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); const tagsList = React.useMemo(() => { if (!userInfoInputArrayWithENSNames?.length) { return null; } const labels = userInfoInputArrayWithENSNames.map(user => { return ( ); }); return
    {labels}
    ; }, [userInfoInputArrayWithENSNames, onRemoveUserFromSelected]); React.useEffect(() => { if (!inputState) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState]); const threadSearchContainerStyles = classNames(css.threadSearchContainer, { [css.fullHeight]: !userInfoInputArray.length, }); return (
    {tagsList} {userSearchResultList}
    ); } export default ChatThreadComposer; diff --git a/web/chat/chat-thread-list-item.react.js b/web/chat/chat-thread-list-item.react.js index 6e0a4b215..c9b4dd53c 100644 --- a/web/chat/chat-thread-list-item.react.js +++ b/web/chat/chat-thread-list-item.react.js @@ -1,161 +1,161 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useAncestorThreads } from 'lib/shared/ancestor-threads.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo, useResolvedThreadInfos, } from 'lib/utils/entity-helpers.js'; import ChatThreadListItemMenu from './chat-thread-list-item-menu.react.js'; import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react.js'; import ChatThreadListSidebar from './chat-thread-list-sidebar.react.js'; import css from './chat-thread-list.css'; import MessagePreview from './message-preview.react.js'; -import ThreadAvatar from '../components/thread-avatar.react.js'; +import ThreadAvatar from '../avatars/thread-avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useOnClickThread, useThreadIsActive, } from '../selectors/thread-selectors.js'; type Props = { +item: ChatThreadItem, }; function ChatThreadListItem(props: Props): React.Node { const { item } = props; const { threadInfo, lastUpdatedTimeIncludingSidebars, mostRecentNonLocalMessage, mostRecentMessageInfo, } = item; const { id: threadID, currentUser } = threadInfo; const unresolvedAncestorThreads = useAncestorThreads(threadInfo); const ancestorThreads = useResolvedThreadInfos(unresolvedAncestorThreads); const lastActivity = shortAbsoluteDate(lastUpdatedTimeIncludingSidebars); const active = useThreadIsActive(threadID); const isCreateMode = useSelector( state => state.navInfo.chatMode === 'create', ); const onClick = useOnClickThread(item.threadInfo); const selectItemIfNotActiveCreation = React.useCallback( (event: SyntheticEvent) => { if (!isCreateMode || !active) { onClick(event); } }, [isCreateMode, active, onClick], ); const containerClassName = classNames({ [css.thread]: true, [css.activeThread]: active, }); const { unread } = currentUser; const titleClassName = classNames({ [css.title]: true, [css.unread]: unread, }); const lastActivityClassName = classNames({ [css.lastActivity]: true, [css.unread]: unread, [css.dark]: !unread, }); const breadCrumbsClassName = classNames(css.breadCrumbs, { [css.unread]: unread, }); let unreadDot; if (unread) { unreadDot =
    ; } const sidebars = item.sidebars.map((sidebarItem, index) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( 0} key={sidebarInfo.threadInfo.id} /> ); } else if (sidebarItem.type === 'seeMore') { return ( ); } else { return
    ; } }); const ancestorPath = ancestorThreads.map((thread, idx) => { const isNotLast = idx !== ancestorThreads.length - 1; const chevron = isNotLast && ( ); return ( {thread.uiName} {chevron} ); }); const { uiName } = useResolvedThreadInfo(threadInfo); return ( <>
    {unreadDot}

    {ancestorPath}

    {uiName}
    {lastActivity}
    {sidebars} ); } export default ChatThreadListItem; diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 71941e232..47094ea57 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,240 +1,240 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { Circle as CircleIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react.js'; import InlineEngagement from './inline-engagement.react.js'; +import UserAvatar from '../avatars/user-avatar.react.js'; import CommIcon from '../CommIcon.react.js'; -import UserAvatar from '../components/user-avatar.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import { useMessageTooltip } from '../utils/tooltip-action-utils.js'; import { tooltipPositions } from '../utils/tooltip-utils.js'; const availableTooltipPositionsForViewerMessage = [ tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; const availableTooltipPositionsForNonViewerMessage = [ tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; type BaseProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, +sendFailed: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, }; type BaseConfig = React.Config; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, +onMouseLeave: ?() => mixed, +onMouseEnter: (event: SyntheticEvent) => mixed, +containsInlineEngagement: boolean, +stringForUser: ?string, }; class ComposedMessage extends React.PureComponent { static defaultProps: { +borderRadius: number } = { borderRadius: 8, }; render(): React.Node { assertComposableMessageType(this.props.item.messageInfo.type); const { borderRadius, item, threadInfo, shouldDisplayPinIndicator } = this.props; const { hasBeenEdited, isPinned } = item; const { id, creator } = item.messageInfo; const threadColor = threadInfo.color; const { isViewer } = creator; const contentClassName = classNames({ [css.content]: true, [css.viewerContent]: isViewer, [css.nonViewerContent]: !isViewer, }); const messageBoxContainerClassName = classNames({ [css.messageBoxContainer]: true, [css.fixedWidthMessageBoxContainer]: this.props.fixedWidth, }); const messageBoxClassName = classNames({ [css.messageBox]: true, [css.fixedWidthMessageBox]: this.props.fixedWidth, }); const messageBoxStyle = { borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius, borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius, }; let authorName = null; const { stringForUser } = this.props; if (stringForUser) { authorName = {stringForUser}; } let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconSpan; let deliveryIconColor = threadColor; if (id !== null && id !== undefined) { deliveryIconSpan = ; } else if (this.props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; failedSendInfo = ; } else { deliveryIconSpan = ; } deliveryIcon = (
    {deliveryIconSpan}
    ); } let inlineEngagement = null; const label = getMessageLabel(hasBeenEdited, threadInfo); if ( (this.props.containsInlineEngagement && item.threadCreatedFromMessage) || Object.keys(item.reactions).length > 0 || label ) { const positioning = isViewer ? 'right' : 'left'; inlineEngagement = (
    ); } let avatar; if (!isViewer && item.endsCluster) { avatar = (
    ); } else if (!isViewer) { avatar =
    ; } const pinIconPositioning = isViewer ? 'left' : 'right'; const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; const pinIconContainerClassName = classNames({ [css.pinIconContainer]: true, [css.pinIconLeft]: pinIconPositioning === 'left', [css.pinIconRight]: pinIconPositioning === 'right', }); let pinIcon; if (isPinned && shouldDisplayPinIndicator) { pinIcon = (
    ); } return ( {authorName}
    {avatar}
    {pinIcon}
    {this.props.children}
    {deliveryIcon}
    {failedSendInfo} {inlineEngagement}
    ); } } type ConnectedConfig = React.Config< BaseProps, typeof ComposedMessage.defaultProps, >; const ConnectedComposedMessage: React.ComponentType = React.memo(function ConnectedComposedMessage(props) { const { item, threadInfo } = props; const inputState = React.useContext(InputStateContext); const { creator } = props.item.messageInfo; const { isViewer } = creator; const availablePositions = isViewer ? availableTooltipPositionsForViewerMessage : availableTooltipPositionsForNonViewerMessage; const containsInlineEngagement = !!item.threadCreatedFromMessage; const { onMouseLeave, onMouseEnter } = useMessageTooltip({ item, threadInfo, availablePositions, }); const shouldShowUsername = !isViewer && item.startsCluster; const stringForUser = useStringForUser(shouldShowUsername ? creator : null); return ( ); }); export default ConnectedComposedMessage; diff --git a/web/chat/thread-top-bar.react.js b/web/chat/thread-top-bar.react.js index 81269a12d..b9054cc9e 100644 --- a/web/chat/thread-top-bar.react.js +++ b/web/chat/thread-top-bar.react.js @@ -1,82 +1,82 @@ // @flow import * as React from 'react'; import { ChevronRight } from 'react-feather'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import ThreadMenu from './thread-menu.react.js'; import css from './thread-top-bar.css'; -import ThreadAvatar from '../components/thread-avatar.react.js'; +import ThreadAvatar from '../avatars/thread-avatar.react.js'; import { InputStateContext } from '../input/input-state.js'; import MessageResultsModal from '../modals/chat/message-results-modal.react.js'; type ThreadTopBarProps = { +threadInfo: ThreadInfo, }; function ThreadTopBar(props: ThreadTopBarProps): React.Node { const { threadInfo } = props; const { pushModal } = useModalContext(); let threadMenu = null; if (!threadIsPending(threadInfo.id)) { threadMenu = ; } // To allow the pinned messages modal to be re-used by the message search // modal, it will be useful to make the modal accept a prop that defines it's // name, instead of setting it directly in the modal. const bannerText = React.useMemo(() => { if (!threadInfo.pinnedCount || threadInfo.pinnedCount === 0) { return ''; } const messageNoun = threadInfo.pinnedCount === 1 ? 'message' : 'messages'; return `${threadInfo.pinnedCount} pinned ${messageNoun}`; }, [threadInfo.pinnedCount]); const inputState = React.useContext(InputStateContext); const pushThreadPinsModal = React.useCallback(() => { pushModal( , ); }, [pushModal, inputState, threadInfo, bannerText]); const pinnedCountBanner = React.useMemo(() => { if (!bannerText) { return null; } return ( ); }, [bannerText, pushThreadPinsModal]); const { uiName } = useResolvedThreadInfo(threadInfo); return ( <>
    {uiName}
    {threadMenu}
    {pinnedCountBanner} ); } export default ThreadTopBar; diff --git a/web/modals/chat/message-reactions-modal.react.js b/web/modals/chat/message-reactions-modal.react.js index 5d63d1f46..b22ebb556 100644 --- a/web/modals/chat/message-reactions-modal.react.js +++ b/web/modals/chat/message-reactions-modal.react.js @@ -1,43 +1,43 @@ // @flow import * as React from 'react'; import type { ReactionInfo } from 'lib/selectors/chat-selectors.js'; import { useMessageReactionsList } from 'lib/shared/reaction-utils.js'; import css from './message-reactions-modal.css'; -import UserAvatar from '../../components/user-avatar.react.js'; +import UserAvatar from '../../avatars/user-avatar.react.js'; import Modal from '../modal.react.js'; type Props = { +onClose: () => void, +reactions: ReactionInfo, }; function MessageReactionsModal(props: Props): React.Node { const { onClose, reactions } = props; const messageReactionsList = useMessageReactionsList(reactions); const reactionsList = React.useMemo( () => messageReactionsList.map(messageReactionUser => (
    {messageReactionUser.username}
    {messageReactionUser.reaction}
    )), [messageReactionsList], ); return (
    {reactionsList}
    ); } export default MessageReactionsModal; diff --git a/web/modals/components/add-members-item.react.js b/web/modals/components/add-members-item.react.js index e9d80ed70..699ce8afc 100644 --- a/web/modals/components/add-members-item.react.js +++ b/web/modals/components/add-members-item.react.js @@ -1,55 +1,55 @@ // @flow import * as React from 'react'; import type { UserListItem } from 'lib/types/user-types.js'; import css from './add-members.css'; +import UserAvatar from '../../avatars/user-avatar.react.js'; import Button from '../../components/button.react.js'; -import UserAvatar from '../../components/user-avatar.react.js'; type AddMembersItemProps = { +userInfo: UserListItem, +onClick: (userID: string) => void, +userAdded: boolean, }; function AddMemberItem(props: AddMembersItemProps): React.Node { const { userInfo, onClick, userAdded = false } = props; const canBeAdded = !userInfo.alertText; const onClickCallback = React.useCallback(() => { if (!canBeAdded) { return; } onClick(userInfo.id); }, [canBeAdded, onClick, userInfo.id]); const action = React.useMemo(() => { if (!canBeAdded) { return userInfo.alertTitle; } if (userAdded) { return Remove; } else { return 'Add'; } }, [canBeAdded, userAdded, userInfo.alertTitle]); return ( ); } export default AddMemberItem; diff --git a/web/modals/threads/members/member.react.js b/web/modals/threads/members/member.react.js index e76075ba7..55ed7e0fd 100644 --- a/web/modals/threads/members/member.react.js +++ b/web/modals/threads/members/member.react.js @@ -1,168 +1,168 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { removeUsersFromThread, changeThreadMemberRoles, } from 'lib/actions/thread-actions.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { memberIsAdmin, removeMemberFromThread, switchMemberAdminRoleInThread, getAvailableThreadMemberActions, } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { SetState } from 'lib/types/hook-types.js'; import { type RelativeMemberInfo, type ThreadInfo, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import css from './members-modal.css'; +import UserAvatar from '../../../avatars/user-avatar.react.js'; import Label from '../../../components/label.react.js'; import MenuItem from '../../../components/menu-item.react.js'; import Menu from '../../../components/menu.react.js'; -import UserAvatar from '../../../components/user-avatar.react.js'; type Props = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +setOpenMenu: SetState, +isMenuOpen: boolean, }; function ThreadMember(props: Props): React.Node { const { memberInfo, threadInfo, setOpenMenu, isMenuOpen } = props; const userName = stringForUser(memberInfo); const { roles } = threadInfo; const { role } = memberInfo; const onMenuChange = React.useCallback( menuOpen => { if (menuOpen) { setOpenMenu(() => memberInfo.id); } else { setOpenMenu(menu => (menu === memberInfo.id ? null : menu)); } }, [memberInfo.id, setOpenMenu], ); const dispatchActionPromise = useDispatchActionPromise(); const boundRemoveUsersFromThread = useServerCall(removeUsersFromThread); const onClickRemoveUser = React.useCallback( () => removeMemberFromThread( threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread, ), [boundRemoveUsersFromThread, dispatchActionPromise, memberInfo, threadInfo], ); const isCurrentlyAdmin = memberIsAdmin(memberInfo, threadInfo); const boundChangeThreadMemberRoles = useServerCall(changeThreadMemberRoles); const onMemberAdminRoleToggled = React.useCallback( () => switchMemberAdminRoleInThread( threadInfo, memberInfo, isCurrentlyAdmin, dispatchActionPromise, boundChangeThreadMemberRoles, ), [ boundChangeThreadMemberRoles, dispatchActionPromise, isCurrentlyAdmin, memberInfo, threadInfo, ], ); const menuItems = React.useMemo( () => getAvailableThreadMemberActions(memberInfo, threadInfo).map(action => { if (action === 'remove_admin') { return ( ); } if (action === 'make_admin') { return ( ); } if (action === 'remove_user') { return ( ); } return null; }), [memberInfo, onClickRemoveUser, onMemberAdminRoleToggled, threadInfo], ); const userSettingsIcon = React.useMemo( () => , [], ); const roleName = role && roles[role].name; const label = React.useMemo( () => , [roleName], ); const memberContainerClasses = classNames(css.memberContainer, { [css.memberContainerWithMenuOpen]: isMenuOpen, }); return (
    {userName} {label}
    {menuItems}
    ); } export default ThreadMember; diff --git a/web/modals/threads/sidebars/sidebar.react.js b/web/modals/threads/sidebars/sidebar.react.js index 380f62945..9c9c7a9a9 100644 --- a/web/modals/threads/sidebars/sidebar.react.js +++ b/web/modals/threads/sidebars/sidebar.react.js @@ -1,101 +1,101 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './sidebars-modal.css'; +import ThreadAvatar from '../../../avatars/thread-avatar.react.js'; import Button from '../../../components/button.react.js'; -import ThreadAvatar from '../../../components/thread-avatar.react.js'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js'; import { useOnClickThread } from '../../../selectors/thread-selectors.js'; type Props = { +sidebar: ChatThreadItem, +isLastItem?: boolean, }; function Sidebar(props: Props): React.Node { const { sidebar, isLastItem } = props; const { threadInfo, lastUpdatedTime, mostRecentMessageInfo } = sidebar; const { unread } = threadInfo.currentUser; const { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const sidebarInfoClassName = classNames({ [css.sidebarInfo]: true, [css.unread]: unread, }); const previewTextClassName = classNames([ css.longTextEllipsis, css.avatarOffset, ]); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTime), [lastUpdatedTime], ); const messagePreviewResult = useMessagePreview( mostRecentMessageInfo, threadInfo, getDefaultTextMessageRules().simpleMarkdownRules, ); const lastMessage = React.useMemo(() => { if (!messagePreviewResult) { return
    No messages
    ; } const { message, username } = messagePreviewResult; const previewText = username ? `${username.text}: ${message.text}` : message.text; return ( <>
    {previewText}
    {lastActivity}
    ); }, [lastActivity, messagePreviewResult, previewTextClassName]); const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default Sidebar; diff --git a/web/modals/threads/subchannels/subchannel.react.js b/web/modals/threads/subchannels/subchannel.react.js index 8ecd3aa5d..27f72c215 100644 --- a/web/modals/threads/subchannels/subchannel.react.js +++ b/web/modals/threads/subchannels/subchannel.react.js @@ -1,89 +1,89 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { type ChatThreadItem } from 'lib/selectors/chat-selectors.js'; import { useMessagePreview } from 'lib/shared/message-utils.js'; import { shortAbsoluteDate } from 'lib/utils/date-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './subchannels-modal.css'; +import ThreadAvatar from '../../../avatars/thread-avatar.react.js'; import Button from '../../../components/button.react.js'; -import ThreadAvatar from '../../../components/thread-avatar.react.js'; import { getDefaultTextMessageRules } from '../../../markdown/rules.react.js'; import { useOnClickThread } from '../../../selectors/thread-selectors.js'; type Props = { +chatThreadItem: ChatThreadItem, }; function Subchannel(props: Props): React.Node { const { chatThreadItem } = props; const { threadInfo, mostRecentMessageInfo, lastUpdatedTimeIncludingSidebars, } = chatThreadItem; const { unread } = threadInfo.currentUser; const subchannelTitleClassName = classNames({ [css.subchannelInfo]: true, [css.unread]: unread, }); const { popModal } = useModalContext(); const navigateToThread = useOnClickThread(threadInfo); const onClickThread = React.useCallback( event => { popModal(); navigateToThread(event); }, [popModal, navigateToThread], ); const lastActivity = React.useMemo( () => shortAbsoluteDate(lastUpdatedTimeIncludingSidebars), [lastUpdatedTimeIncludingSidebars], ); const messagePreviewResult = useMessagePreview( mostRecentMessageInfo, threadInfo, getDefaultTextMessageRules().simpleMarkdownRules, ); const lastMessage = React.useMemo(() => { if (!messagePreviewResult) { return
    No messages
    ; } const { message, username } = messagePreviewResult; const previewText = username ? `${username.text}: ${message.text}` : message.text; return ( <>
    {previewText}
    {lastActivity}
    ); }, [lastActivity, messagePreviewResult]); const { uiName } = useResolvedThreadInfo(threadInfo); return ( ); } export default Subchannel; diff --git a/web/modals/threads/thread-picker-modal.react.js b/web/modals/threads/thread-picker-modal.react.js index 4f658649e..86311a28d 100644 --- a/web/modals/threads/thread-picker-modal.react.js +++ b/web/modals/threads/thread-picker-modal.react.js @@ -1,140 +1,140 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { createSelector } from 'reselect'; import { useGlobalThreadSearchIndex } from 'lib/selectors/nav-selectors.js'; import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import css from './thread-picker-modal.css'; +import ThreadAvatar from '../../avatars/thread-avatar.react.js'; import Button from '../../components/button.react.js'; import Search from '../../components/search.react.js'; -import ThreadAvatar from '../../components/thread-avatar.react.js'; import { useSelector } from '../../redux/redux-utils.js'; import Modal, { type ModalOverridableProps } from '../modal.react.js'; type OptionProps = { +threadInfo: ThreadInfo, +createNewEntry: (threadID: string) => void, +onCloseModal: () => void, }; function ThreadPickerOption(props: OptionProps) { const { threadInfo, createNewEntry, onCloseModal } = props; const onClickThreadOption = React.useCallback(() => { createNewEntry(threadInfo.id); onCloseModal(); }, [threadInfo.id, createNewEntry, onCloseModal]); const { uiName } = useResolvedThreadInfo(threadInfo); return (
    ); } type Props = { ...ModalOverridableProps, +createNewEntry: (threadID: string) => void, }; function ThreadPickerModal(props: Props): React.Node { const { createNewEntry, ...modalProps } = props; const onScreenThreadInfos = useSelector(onScreenEntryEditableThreadInfos); const searchIndex = useGlobalThreadSearchIndex(); invariant( onScreenThreadInfos.length > 0, "ThreadPicker can't be open when onScreenThreadInfos is empty", ); const [searchText, setSearchText] = React.useState(''); const [searchResults, setSearchResults] = React.useState>( new Set(), ); const searchRef = React.useRef(); React.useEffect(() => { searchRef.current?.focus(); }, []); const onChangeSearchText = React.useCallback( (text: string) => { const results = searchIndex.getSearchResults(text); setSearchText(text); setSearchResults(new Set(results)); }, [searchIndex], ); const listDataSelector = createSelector( state => state.onScreenThreadInfos, state => state.searchText, state => state.searchResults, ( threadInfos: $ReadOnlyArray, text: string, results: Set, ) => text ? threadInfos.filter(threadInfo => results.has(threadInfo.id)) : [...threadInfos], ); const threads = useSelector(() => listDataSelector({ onScreenThreadInfos, searchText, searchResults, }), ); const threadPickerContent = React.useMemo(() => { const options = threads.map(threadInfo => ( )); if (options.length === 0 && searchText.length > 0) { return (
    No results for {searchText}
    ); } else { return options; } }, [threads, createNewEntry, modalProps.onClose, searchText]); return (
    {threadPickerContent}
    ); } export default ThreadPickerModal; diff --git a/web/navigation-panels/nav-state-info-bar.react.js b/web/navigation-panels/nav-state-info-bar.react.js index cbab4c660..5727fb925 100644 --- a/web/navigation-panels/nav-state-info-bar.react.js +++ b/web/navigation-panels/nav-state-info-bar.react.js @@ -1,67 +1,67 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import ThreadAncestors from './chat-thread-ancestors.react.js'; import css from './nav-state-info-bar.css'; -import ThreadAvatar from '../components/thread-avatar.react.js'; +import ThreadAvatar from '../avatars/thread-avatar.react.js'; type NavStateInfoBarProps = { +threadInfo: ThreadInfo, }; function NavStateInfoBar(props: NavStateInfoBarProps): React.Node { const { threadInfo } = props; return ( <>
    ); } type PossiblyEmptyNavStateInfoBarProps = { +threadInfoInput: ?ThreadInfo, }; function PossiblyEmptyNavStateInfoBar( props: PossiblyEmptyNavStateInfoBarProps, ): React.Node { const { threadInfoInput } = props; const [threadInfo, setThreadInfo] = React.useState(threadInfoInput); React.useEffect(() => { if (threadInfoInput !== threadInfo) { if (threadInfoInput) { setThreadInfo(threadInfoInput); } else { const timeout = setTimeout(() => { setThreadInfo(null); }, 200); return () => clearTimeout(timeout); } } return undefined; }, [threadInfoInput, threadInfo]); const content = React.useMemo(() => { if (threadInfo) { return ; } else { return null; } }, [threadInfo]); const classes = classnames(css.topBarContainer, { [css.hide]: !threadInfoInput, [css.show]: threadInfoInput, }); return
    {content}
    ; } export default PossiblyEmptyNavStateInfoBar; diff --git a/web/settings/account-settings.react.js b/web/settings/account-settings.react.js index d310d9004..d07b623e1 100644 --- a/web/settings/account-settings.react.js +++ b/web/settings/account-settings.react.js @@ -1,113 +1,113 @@ // @flow import * as React from 'react'; import { logOut, logOutActionTypes } from 'lib/actions/user-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js'; import { accountHasPassword } from 'lib/shared/account-utils.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import css from './account-settings.css'; import PasswordChangeModal from './password-change-modal.js'; import BlockListModal from './relationship/block-list-modal.react.js'; import FriendListModal from './relationship/friend-list-modal.react.js'; +import UserAvatar from '../avatars/user-avatar.react.js'; import Button from '../components/button.react.js'; -import UserAvatar from '../components/user-avatar.react.js'; import { useSelector } from '../redux/redux-utils.js'; function AccountSettings(): React.Node { const sendLogoutRequest = useServerCall(logOut); const preRequestUserState = useSelector(preRequestUserStateSelector); const dispatchActionPromise = useDispatchActionPromise(); const logOutUser = React.useCallback( () => dispatchActionPromise( logOutActionTypes, sendLogoutRequest(preRequestUserState), ), [dispatchActionPromise, preRequestUserState, sendLogoutRequest], ); const { pushModal, popModal } = useModalContext(); const showPasswordChangeModal = React.useCallback( () => pushModal(), [pushModal], ); const openFriendList = React.useCallback( () => pushModal(), [popModal, pushModal], ); const openBlockList = React.useCallback( () => pushModal(), [popModal, pushModal], ); const isAccountWithPassword = useSelector(state => accountHasPassword(state.currentUserInfo), ); const currentUserInfo = useSelector(state => state.currentUserInfo); const stringForUser = useStringForUser(currentUserInfo); if (!currentUserInfo || currentUserInfo.anonymous) { return null; } let changePasswordSection; if (isAccountWithPassword) { changePasswordSection = (
  • Password ******
  • ); } return (

    My Account

    • {'Logged in as '} {stringForUser}

    • {changePasswordSection}
    • Friend List
    • Block List
    ); } export default AccountSettings; diff --git a/web/settings/relationship/block-list-row.react.js b/web/settings/relationship/block-list-row.react.js index abeecea27..5faf9e095 100644 --- a/web/settings/relationship/block-list-row.react.js +++ b/web/settings/relationship/block-list-row.react.js @@ -1,40 +1,40 @@ // @flow import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useRelationshipCallbacks } from 'lib/hooks/relationship-prompt.js'; import css from './user-list-row.css'; import type { UserRowProps } from './user-list.react.js'; +import UserAvatar from '../../avatars/user-avatar.react.js'; import MenuItem from '../../components/menu-item.react.js'; import Menu from '../../components/menu.react.js'; -import UserAvatar from '../../components/user-avatar.react.js'; function BlockListRow(props: UserRowProps): React.Node { const { userInfo, onMenuVisibilityChange } = props; const { unblockUser } = useRelationshipCallbacks(userInfo.id); const editIcon = ; return (
    {userInfo.username}
    ); } export default BlockListRow; diff --git a/web/settings/relationship/friend-list-row.react.js b/web/settings/relationship/friend-list-row.react.js index 5eb42c6c5..a24c92221 100644 --- a/web/settings/relationship/friend-list-row.react.js +++ b/web/settings/relationship/friend-list-row.react.js @@ -1,93 +1,93 @@ // @flow import * as React from 'react'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useRelationshipCallbacks } from 'lib/hooks/relationship-prompt.js'; import { userRelationshipStatus } from 'lib/types/relationship-types.js'; import css from './user-list-row.css'; import type { UserRowProps } from './user-list.react.js'; +import UserAvatar from '../../avatars/user-avatar.react.js'; import Button from '../../components/button.react.js'; import MenuItem from '../../components/menu-item.react.js'; import Menu from '../../components/menu.react.js'; -import UserAvatar from '../../components/user-avatar.react.js'; const dangerButtonColor = { color: 'var(--btn-bg-danger)', }; function FriendListRow(props: UserRowProps): React.Node { const { userInfo, onMenuVisibilityChange } = props; const { friendUser, unfriendUser } = useRelationshipCallbacks(userInfo.id); const buttons = React.useMemo(() => { if (userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT) { return ( ); } if ( userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( <> ); } if (userInfo.relationshipStatus === userRelationshipStatus.FRIEND) { const editIcon = ; return (
    ); } return undefined; }, [ friendUser, unfriendUser, userInfo.relationshipStatus, onMenuVisibilityChange, ]); return (
    {userInfo.username}
    {buttons}
    ); } export default FriendListRow; diff --git a/web/sidebar/community-creation/community-creation-modal.react.js b/web/sidebar/community-creation/community-creation-modal.react.js index 420c46c84..3bd02e609 100644 --- a/web/sidebar/community-creation/community-creation-modal.react.js +++ b/web/sidebar/community-creation/community-creation-modal.react.js @@ -1,208 +1,208 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { newThread, newThreadActionTypes } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { NewThreadResult } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import CommunityCreationKeyserverLabel from './community-creation-keyserver-label.react.js'; import CommunityCreationMembersModal from './community-creation-members-modal.react.js'; import css from './community-creation-modal.css'; +import UserAvatar from '../../avatars/user-avatar.react.js'; import CommIcon from '../../CommIcon.react.js'; import Button, { buttonThemes } from '../../components/button.react.js'; import EnumSettingsOption from '../../components/enum-settings-option.react.js'; -import UserAvatar from '../../components/user-avatar.react.js'; import LoadingIndicator from '../../loading-indicator.react.js'; import Input from '../../modals/input.react.js'; import Modal from '../../modals/modal.react.js'; import { updateNavInfoActionType } from '../../redux/action-types.js'; import { useSelector } from '../../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../../selectors/nav-selectors.js'; const announcementStatements = [ { statement: `This option sets the community’s root channel to an ` + `announcement channel. Only admins and other admin-appointed ` + `roles can send messages in an announcement channel.`, isStatementValid: true, styleStatementBasedOnValidity: false, }, ]; const createNewCommunityLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); function CommunityCreationModal(): React.Node { const modalContext = useModalContext(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); const calendarQueryFunc = useSelector(nonThreadCalendarQuery); const [errorMessage, setErrorMessage] = React.useState(); const [pendingCommunityName, setPendingCommunityName] = React.useState(''); const onChangePendingCommunityName = React.useCallback( (event: SyntheticEvent) => { setErrorMessage(); setPendingCommunityName(event.currentTarget.value); }, [], ); const [announcementSetting, setAnnouncementSetting] = React.useState(false); const onAnnouncementSelected = React.useCallback(() => { setErrorMessage(); setAnnouncementSetting(!announcementSetting); }, [announcementSetting]); const callCreateNewCommunity = React.useCallback(async () => { const calendarQuery = calendarQueryFunc(); try { const newThreadResult: NewThreadResult = await callNewThread({ name: pendingCommunityName, type: announcementSetting ? threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT : threadTypes.COMMUNITY_ROOT, calendarQuery, }); return newThreadResult; } catch (e) { setErrorMessage('Community creation failed. Please try again.'); throw e; } }, [ announcementSetting, calendarQueryFunc, callNewThread, pendingCommunityName, ]); const createNewCommunity = React.useCallback(async () => { setErrorMessage(); const newThreadResultPromise = callCreateNewCommunity(); dispatchActionPromise(newThreadActionTypes, newThreadResultPromise); const newThreadResult: NewThreadResult = await newThreadResultPromise; const { newThreadID } = newThreadResult; await dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: newThreadID, }, }); modalContext.popModal(); modalContext.pushModal( , ); }, [callCreateNewCommunity, dispatch, dispatchActionPromise, modalContext]); const megaphoneIcon = React.useMemo( () => , [], ); const avatarNodeEnabled = false; let avatarNode; if (avatarNodeEnabled) { avatarNode = (
    ); } const createNewCommunityLoadingStatus: LoadingStatus = useSelector( createNewCommunityLoadingStatusSelector, ); let buttonContent; if (createNewCommunityLoadingStatus === 'loading') { buttonContent = ( ); } else if (errorMessage) { buttonContent = errorMessage; } else { buttonContent = 'Create community'; } return (
    {avatarNode}
    Community Name
    You may edit your community’s image and name later.

    Optional settings
    ); } export default CommunityCreationModal; diff --git a/web/sidebar/community-drawer-item-community.react.js b/web/sidebar/community-drawer-item-community.react.js index d7b249ff5..2bad1d8d1 100644 --- a/web/sidebar/community-drawer-item-community.react.js +++ b/web/sidebar/community-drawer-item-community.react.js @@ -1,98 +1,98 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { getCommunityDrawerItemCommunityHandler } from './community-drawer-item-community-handlers.react.js'; import css from './community-drawer-item.css'; import type { DrawerItemProps } from './community-drawer-item.react.js'; import { getChildren, getExpandButton, } from './community-drawer-utils.react.js'; -import ThreadAvatar from '../components/thread-avatar.react.js'; +import ThreadAvatar from '../avatars/thread-avatar.react.js'; function CommunityDrawerItemCommunity(props: DrawerItemProps): React.Node { const { itemData: { threadInfo, itemChildren, hasSubchannelsButton, labelStyle }, paddingLeft, expandable = true, handlerType, } = props; const Handler = getCommunityDrawerItemCommunityHandler(handlerType); const [handler, setHandler] = React.useState({ onClick: () => {}, isActive: false, expanded: false, toggleExpanded: () => {}, }); const children = React.useMemo( () => getChildren({ expanded: handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, }), [ handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, ], ); const itemExpandButton = React.useMemo( () => getExpandButton({ expandable, childrenLength: itemChildren?.length, hasSubchannelsButton, onExpandToggled: null, expanded: handler.expanded, }), [expandable, itemChildren?.length, hasSubchannelsButton, handler.expanded], ); const classes = classnames({ [css.communityBase]: true, [css.communityExpanded]: handler.expanded, }); const { uiName } = useResolvedThreadInfo(threadInfo); const titleLabel = classnames({ [css[labelStyle]]: true, [css.activeTitle]: handler.isActive, }); const style = React.useMemo(() => ({ paddingLeft }), [paddingLeft]); return ( ); } const MemoizedCommunityDrawerItemCommunity: React.ComponentType = React.memo(CommunityDrawerItemCommunity); export default MemoizedCommunityDrawerItemCommunity; diff --git a/web/sidebar/community-drawer-item.react.js b/web/sidebar/community-drawer-item.react.js index 3821b57fd..f7d616c8e 100644 --- a/web/sidebar/community-drawer-item.react.js +++ b/web/sidebar/community-drawer-item.react.js @@ -1,116 +1,116 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import type { CommunityDrawerItemData } from 'lib/utils/drawer-utils.react.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import type { HandlerProps } from './community-drawer-item-handlers.react.js'; import { getCommunityDrawerItemHandler } from './community-drawer-item-handlers.react.js'; import css from './community-drawer-item.css'; import { getChildren, getExpandButton, } from './community-drawer-utils.react.js'; -import ThreadAvatar from '../components/thread-avatar.react.js'; +import ThreadAvatar from '../avatars/thread-avatar.react.js'; import type { NavigationTab } from '../types/nav-types.js'; export type DrawerItemProps = { +itemData: CommunityDrawerItemData, +paddingLeft: number, +expandable?: boolean, +handlerType: NavigationTab, }; function CommunityDrawerItem(props: DrawerItemProps): React.Node { const { itemData: { threadInfo, itemChildren, hasSubchannelsButton, labelStyle }, paddingLeft, expandable = true, handlerType, } = props; const [handler, setHandler] = React.useState({ onClick: () => {}, expanded: false, toggleExpanded: () => {}, }); const Handler = getCommunityDrawerItemHandler(handlerType); const children = React.useMemo( () => getChildren({ expanded: handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, }), [ handler.expanded, hasSubchannelsButton, itemChildren, paddingLeft, threadInfo, expandable, handlerType, ], ); const itemExpandButton = React.useMemo( () => getExpandButton({ expandable, childrenLength: itemChildren.length, hasSubchannelsButton, onExpandToggled: handler.toggleExpanded, expanded: handler.expanded, }), [ expandable, itemChildren.length, hasSubchannelsButton, handler.toggleExpanded, handler.expanded, ], ); const { uiName } = useResolvedThreadInfo(threadInfo); const titleLabel = classnames({ [css[labelStyle]]: true, [css.activeTitle]: handler.isActive, }); const style = React.useMemo(() => ({ paddingLeft }), [paddingLeft]); return ( <>
    {itemExpandButton}
    {uiName}
    {children}
    ); } export type CommunityDrawerItemChatProps = { +itemData: CommunityDrawerItemData, +paddingLeft: number, +expandable?: boolean, +handler: React.ComponentType, }; const MemoizedCommunityDrawerItem: React.ComponentType = React.memo(CommunityDrawerItem); export default MemoizedCommunityDrawerItem; diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js index 84c6b445a..cc44faf4a 100644 --- a/web/utils/typeahead-utils.js +++ b/web/utils/typeahead-utils.js @@ -1,215 +1,215 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { oldValidUsernameRegexString } from 'lib/shared/account-utils.js'; import { getNewTextAndSelection } from 'lib/shared/mention-utils.js'; import { stringForUserExplicit } from 'lib/shared/user-utils.js'; import type { SetState } from 'lib/types/hook-types.js'; import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; +import UserAvatar from '../avatars/user-avatar.react.js'; import { typeaheadStyle } from '../chat/chat-constants.js'; import css from '../chat/typeahead-tooltip.css'; import Button from '../components/button.react.js'; -import UserAvatar from '../components/user-avatar.react.js'; const webTypeaheadRegex: RegExp = new RegExp( `(?(?:^(?:.|\n)*\\s+)|^)@(?${oldValidUsernameRegexString})?$`, ); export type TypeaheadTooltipAction = { +key: string, +execute: () => mixed, +actionButtonContent: { +userID: string, +username: string }, }; export type TooltipPosition = { +top: number, +left: number, }; function getCaretOffsets( textarea: HTMLTextAreaElement, text: string, ): { caretTopOffset: number, caretLeftOffset: number } { if (!textarea) { return { caretTopOffset: 0, caretLeftOffset: 0 }; } // terribly hacky but it works I guess :D // we had to use it, as it's hard to count lines in textarea // and track cursor position within it as // lines can be wrapped into new lines without \n character // as result of overflow const textareaStyle: CSSStyleDeclaration = window.getComputedStyle( textarea, null, ); const div = document.createElement('div'); for (const styleName of textareaStyle) { div.style.setProperty(styleName, textareaStyle.getPropertyValue(styleName)); } div.style.display = 'inline-block'; div.style.position = 'absolute'; div.textContent = text; const span = document.createElement('span'); span.textContent = textarea.value.slice(text.length); div.appendChild(span); document.body?.appendChild(div); const { offsetTop, offsetLeft } = span; document.body?.removeChild(div); const textareaWidth = parseInt(textareaStyle.getPropertyValue('width')); const caretLeftOffset = offsetLeft + typeaheadStyle.tooltipWidth > textareaWidth ? textareaWidth - typeaheadStyle.tooltipWidth : offsetLeft; return { caretTopOffset: offsetTop - textarea.scrollTop, caretLeftOffset, }; } export type GetTypeaheadTooltipActionsParams = { +inputStateDraft: string, +inputStateSetDraft: (draft: string) => mixed, +inputStateSetTextCursorPosition: (newPosition: number) => mixed, +suggestedUsers: $ReadOnlyArray, +textBeforeAtSymbol: string, +usernamePrefix: string, }; function getTypeaheadTooltipActions( params: GetTypeaheadTooltipActionsParams, ): $ReadOnlyArray { const { inputStateDraft, inputStateSetDraft, inputStateSetTextCursorPosition, suggestedUsers, textBeforeAtSymbol, usernamePrefix, } = params; return suggestedUsers .filter( suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous', ) .map(suggestedUser => ({ key: suggestedUser.id, execute: () => { const { newText, newSelectionStart } = getNewTextAndSelection( textBeforeAtSymbol, inputStateDraft, usernamePrefix, suggestedUser, ); inputStateSetDraft(newText); inputStateSetTextCursorPosition(newSelectionStart); }, actionButtonContent: { userID: suggestedUser.id, username: stringForUserExplicit(suggestedUser), }, })); } function getTypeaheadTooltipButtons( setChosenPositionInOverlay: SetState, chosenPositionInOverlay: number, actions: $ReadOnlyArray, ): $ReadOnlyArray { return actions.map((action, idx) => { const { key, execute, actionButtonContent } = action; const buttonClasses = classNames(css.suggestion, { [css.suggestionHover]: idx === chosenPositionInOverlay, }); const onMouseMove: ( event: SyntheticEvent, ) => mixed = () => { setChosenPositionInOverlay(idx); }; return ( ); }); } function getTypeaheadOverlayScroll( currentScrollTop: number, chosenActionPosition: number, ): number { const upperButtonBoundary = chosenActionPosition * typeaheadStyle.rowHeight; const lowerButtonBoundary = (chosenActionPosition + 1) * typeaheadStyle.rowHeight; if (upperButtonBoundary < currentScrollTop) { return upperButtonBoundary; } else if ( lowerButtonBoundary - typeaheadStyle.tooltipMaxHeight > currentScrollTop ) { return ( lowerButtonBoundary + typeaheadStyle.tooltipVerticalPadding - typeaheadStyle.tooltipMaxHeight ); } return currentScrollTop; } function getTypeaheadTooltipPosition( textarea: HTMLTextAreaElement, actionsLength: number, textBeforeAtSymbol: string, ): TooltipPosition { const { caretTopOffset, caretLeftOffset } = getCaretOffsets( textarea, textBeforeAtSymbol, ); const textareaBoundingClientRect = textarea.getBoundingClientRect(); const top: number = textareaBoundingClientRect.top - Math.min( typeaheadStyle.tooltipVerticalPadding + actionsLength * typeaheadStyle.rowHeight, typeaheadStyle.tooltipMaxHeight, ) - typeaheadStyle.tooltipTopOffset + caretTopOffset; const left: number = textareaBoundingClientRect.left - typeaheadStyle.tooltipLeftOffset + caretLeftOffset; return { top, left }; } export { webTypeaheadRegex, getCaretOffsets, getTypeaheadTooltipActions, getTypeaheadTooltipButtons, getTypeaheadOverlayScroll, getTypeaheadTooltipPosition, };